fix(sdk): generate titles with Responses and subscription streaming - #4968
Conversation
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
Push an update once this is addressed and this check re-runs automatically. This is an automated check - no AI was used to generate this comment. |
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Coverage Report •
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Review
🟢 Good taste — clean, minimal, well-tested.
The PR makes two focused changes:
-
Title generation API dispatch (
title_utils.py): Routes Responses-capable models tollm.responses(messages, store=False)and others tollm.completion(messages). The streaming guardif llm.stream and not llm.requires_streamingcorrectly preserves mandatory streaming for subscription models (which reject non-streaming requests) while disabling it for regular streaming models that have noon_tokencallback wired during title generation. Thestore=Falseparameter is appropriate — title generation should not persist server-side. -
API breakage checker (
check_sdk_api_breakage.py): Converts_ACCEPTED_REMOVED_MEMBERSfrom afrozensetto adictwith per-member reason strings, and addsLLM.modify_paramsas an accepted removal (removed in PR #4954 after its deprecation runway). This is a clean improvement — the diagnostic print now uses the per-member reason instead of a hardcoded message for all entries.
Verification
- All 77 tests pass (12 title-generation + 65 breakage-checker).
- The new
test_title_uses_real_http_transporttest is excellent: it spins up a loopback HTTP server and exercises all three modes (chat, responses, subscription) without mocking the LLM transport, verifying both API endpoint dispatch and streaming behavior. - The breakage test correctly verifies exact membership, negative cases (wrong feature, wrong package), and output content.
Risk Assessment
🟢 LOW — Title generation is a background cosmetic operation that does not affect agent reasoning, tool use, planning, memory, or terminal handling. The same prompt goes to the same model; only the API endpoint path changes for Responses-capable models. Not in the eval-risk category.
Verdict: ✅ Worth merging
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
This PR makes two changes:
- Title generation dispatch (
title_utils.py): Routes Responses-capable models tollm.responses(messages, store=False)instead ofllm.completion(), and preserves streaming for subscription models thatrequires_streaming. - CI tooling (
check_sdk_api_breakage.py): AddsLLM.modify_paramsto the accepted-removals list and converts the data structure fromfrozensettodictto carry per-member acceptance reasons.
Analysis
Title generation (title_utils.py)
The change correctly handles three cases:
- Chat completions models:
uses_responses_api()returns False, socompletion()is used. Streaming is disabled viamodel_copywhennot requires_streaming(same as before). - Responses-capable non-subscription models:
uses_responses_api()returns True, soresponses(store=False)is called. Streaming is disabled first (sincerequires_streamingis False), andresponses()also has an internal fallback that disables streaming whenon_tokenis None — double-safe. - Subscription models:
requires_streamingis True, so streaming is NOT disabled.responses(store=False)is called withstream=Truefromself.stream, and the stream is drained internally by the_one_attemptlogic without needing anon_tokencallback. This is correct — subscription endpoints reject non-streaming requests.
Both completion() and responses() return LLMResponse with a normalized Message, so the title extraction code (response.message.content[0]) works identically for both paths. Verified via _build_responses_result which calls Message.from_llm_responses_output().
The store=False parameter is appropriate for a stateless title-generation call. For subscription models, create_llm already sets litellm_extra_body={"store": False}, and select_responses_options sets out["store"] = False from the explicit parameter — no conflict since both are False.
CI tooling (check_sdk_api_breakage.py)
Clean refactor: frozenset to dict to allow per-member acceptance reason strings. The _is_accepted_removed_member lookup ((package, feature) in _ACCEPTED_REMOVED_MEMBERS) works identically for both types. The diagnostic message now pulls the reason from the dict value instead of hardcoding it. The new test verifies exact matching (rejects wrong package, wrong member name) and checks the diagnostic output contains "PR #4954".
Tests
The new test_title_uses_real_http_transport test is well-structured — it uses a real loopback HTTP server (no mocked LLM transport) and parametrizes across all three modes (chat, responses, subscription). Assertions verify the correct endpoint path, streaming behavior, and store=False for responses/subscription modes.
Risk Assessment
🟢 LOW — This is a focused bugfix to background title-generation utility code (not the agent reasoning loop, tool calling, planning, or condenser behavior). The CI tooling change is a data-structure refactor with exact-match tests. No breaking changes to public APIs, no event type modifications, no security concerns.
The title-generation path is a side-channel call that runs after the first user message; it does not affect the agent's core reasoning loop or benchmark/eval performance. However, since the repo review guidelines are strict about anything adjacent to agent behavior, I'm leaving a COMMENT rather than APPROVE — a human maintainer should make the final call on whether lightweight eval confirmation is needed.
Verdict: Code is correct and clean. No material issues found.
|
GITHUB_REVIEW_POSTED This comment was posted by an AI agent (OpenHands). |
Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.
|
GITHUB_REVIEW_POSTED This comment was posted by an AI agent (OpenHands). |
1 similar comment
|
GITHUB_REVIEW_POSTED This comment was posted by an AI agent (OpenHands). |
e371ac8 to
0130668
Compare
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
🟢 Good taste — minimal, focused change that correctly dispatches title generation through the right API path for each model type.
Summary
The PR makes two changes to generate_title_with_llm:
-
Streaming guard now exempts
requires_streamingmodels — subscription models reject non-streaming requests, so the previous unconditionalstream=Falsewould break them. Theresponses()method already has the samerequires_streamingexemption internally (llm.py:1763), so this is consistent. -
Responses API dispatch — Responses-capable models now use
llm.responses(messages, store=False)instead ofllm.completion(messages), withstore=Falseto avoid persisting transient title-generation calls server-side.
Cross-file verification
LLM.responses()(llm.py:1762) has the same streaming guard ascompletion()but with therequires_streamingexemption, so subscription models correctly stay in streaming mode with internal drain. ✓LLM.completion()(llm.py:1533) does not have therequires_streamingexemption — it unconditionally degrades to non-streaming when noon_tokenis provided. This is pre-existing and not introduced by this PR. In practice, subscription models use the Responses API, so this path isn't hit for them.uses_responses_api()(llm.py:2530) respects explicitapi_modeand falls back to model-feature capability detection forautomode. ✓
Testing
The new test_title_uses_real_http_transport test is well-structured — it uses a real loopback HTTP server instead of mocked LLM transport, covering all three modes (chat, responses, subscription) and asserting on the actual request path, streaming flag, and store=False. This is a real code-path test that would catch regressions.
Risk Assessment
🟢 LOW — Title generation is a cosmetic side-channel that doesn't affect the agent reasoning loop, tool execution, or any benchmark-relevant behavior. No eval evidence needed.
Verdict: ✅ Worth merging — clean, correct, well-tested.
Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.
|
GITHUB_REVIEW_POSTED The review has been successfully posted to GitHub PR #4968 as an APPROVE review (review ID: 5185024480). Summary of findings:
This comment was posted by an AI agent (OpenHands). |
1a84afa to
8cef037
Compare
0130668 to
8110e26
Compare
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
enyst
left a comment
There was a problem hiding this comment.
LGTM
I’m not sure why streaming was disabled. I guess the question is, does it work now? For ChatGPT subscription.
d7d123b to
f5b4a1e
Compare
f5b4a1e to
4ae12e8
Compare
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
Clean refactoring that moves Responses/Chat-Completions dispatch from make_llm_completion/amake_llm_completion utility functions into LLM.generate()/LLM.agenerate() methods on the LLM class itself. All callers migrated to direct generate()/agenerate() calls. Agent-only policy (add_security_risk_prediction=True, store=False) is now explicit at the agent call site rather than baked into a shared wrapper. Title generation no longer force-disables streaming, which correctly enables mandatory streaming for subscription models.
Analysis
Design: Good taste — the dispatch logic belongs on LLM (which owns uses_responses_api()), not in a utility function in agent/utils.py. The generate/agenerate methods are thin dispatch wrappers with **kwargs forwarding, matching the existing completion/responses signatures. The separation of agent-only add_security_risk_prediction from generic generation is the right call.
Behavioral changes verified as safe:
tools=None(new) vstools=[](oldmake_llm_completiondefault): both are falsy, socompletion'sif tools:andresponses'sif tools else Noneproduce identical behavior.- Title generation streaming: old code did
model_copy(update={"stream": False})+completion(). New code callsgenerate()which letscompletion/responseshandle the streaming fallback gracefully (on_token=None-> non-streaming), except forrequires_streamingmodels (subscription) where streaming is mandatory. The real HTTP transport test (test_title_uses_real_http_transport) covers all three modes. ask_agentno longer passesadd_security_risk_prediction=True: correct — it is a sidebar question, not an agent tool-calling path.
Removed functions: make_llm_completion/amake_llm_completion were never exported from __init__.py and no remaining references exist. No public API break.
Tests: The new dispatch tests (test_generate_dispatches_to_configured_api, test_agenerate_dispatches_to_configured_api), the real HTTP transport title test, and the agent step kwargs verification are meaningful and exercise real code paths. The live server test mock updates correctly separate title calls from agent calls.
Risk Assessment
MEDIUM — This PR changes the LLM dispatch path for agent steps, condensation, hook evaluation, cleanup, vision inspection, Ask Oracle, and title generation. While the refactoring is behavior-preserving by design and the changes are well-tested, any change to the agent LLM call path could plausibly affect benchmark/evaluation performance. Per the repo eval-risk policy, I am not approving and flagging this for a human maintainer to decide after running lightweight evals.
Recommendation: Run a lightweight eval (e.g., a small SWE-bench or GAIA subset) to confirm no regression before merging.
4ae12e8 to
432cff4
Compare
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
The PR introduces LLM.generate()/LLM.agenerate() as a generic dispatch method, removes make_llm_completion/amake_llm_completion, and migrates all callers to direct generate()/agenerate() calls. The design is clean — API-mode selection lives in the LLM abstraction, agent-specific policy (add_security_risk_prediction=True, store=False, tools, callbacks) stays explicit at call sites. The title-generation transport test using a real HTTP server is excellent.
However, there is one material correctness bug in the telemetry changes, and the PR touches agent step logic, condenser behavior, and hook evaluation without eval evidence.
🔴 Critical Issue
telemetry.py: __dict__.get() breaks cache token accounting for Pydantic extra fields
The three getattr(usage, ..., 0) calls were replaced with usage.__dict__.get(..., 0) / p_details.__dict__.get(..., 0) to remove them from the forbidden dynamic attributes baseline. But litellm's Usage model uses extra="allow", so dynamically-set fields like cache_read_input_tokens and cache_creation_input_tokens are stored in __pydantic_extra__, not in __dict__. This means __dict__.get("cache_read_input_tokens", 0) always returns 0.
Verified empirically:
u = Usage.model_validate({
'prompt_tokens': 1000, 'cache_read_input_tokens': 800, 'cache_creation_input_tokens': 200
})
getattr(u, 'cache_read_input_tokens', 0) # → 800 (correct)
u.__dict__.get('cache_read_input_tokens', 0) # → 0 (broken)This silently zeroes out Anthropic prompt-caching token counts in telemetry/metrics for both cache reads and cache writes. The same issue affects p_details.__dict__.get("cache_creation_tokens", 0) when cache_creation_tokens is an extra field on the details model.
The _cache_creation_input_tokens change (using isinstance(usage, Usage) + direct attribute access) is fine — that field is a declared PrivateAttr on Usage, so direct access works and won't raise AttributeError.
Suggested fix: Use __pydantic_extra__ or keep getattr and accept the baseline entry. Alternatively, model_dump() would include extra fields but is heavier.
Eval Risk
This PR changes agent step dispatch (agent.py), condenser LLM calls (llm_summarizing_condenser.py), hook evaluation (executor.py), and cleanup profile behavior — all of which could plausibly affect benchmark/evaluation performance. No eval monitor link or human eval confirmation is provided in the PR description. Flagging for a human maintainer to decide after running lightweight evals.
Risk Assessment
- Overall PR: 🟡 MEDIUM
- The telemetry bug is a silent regression in cost/token accounting that won't crash but produces incorrect metrics.
- The agent behavior changes are functionally equivalent to the previous
make_llm_completionwrapper (same dispatch logic, same parameters), so the risk of behavioral regression is low — but eval validation is warranted given the scope. - The CI baseline-shrink guard and the
stream_context.pytry/except change are clean and correct.
Verdict
COMMENT — the telemetry __dict__.get bug should be fixed before merge, and eval validation is recommended given the scope of agent/condenser/hook changes.
701cd8b to
f19a071
Compare
Created by an AI agent (OpenHands) on behalf of @neubig. Co-authored-by: openhands <openhands@all-hands.dev>
Remove agent completion wrappers, migrate auxiliary and agent callers to LLM.generate/agenerate, and preserve agent-only security policy at Agent.step/astep. Created by an AI agent (OpenHands) on behalf of @neubig. Co-authored-by: openhands <openhands@all-hands.dev>
5bb59cc to
f19a071
Compare
f19a071 to
5bb59cc
Compare
HUMAN:
User request, quoted verbatim from @neubig: “OK, create the new PRs and stack 3403 on top of the conversation-scoped API one.” Follow-up: “OK, perform that separation. And update the PR.” Later follow-up: “Don’t we have a make_llm_completion function that could be used here instead?” Clarification: remove
make_llm_completion/amake_llm_completion, migrate callers to generic LLM dispatch, and keep agent-only policy out of generic generation.AGENT:
Why
Responses-capable and subscription models need correct API dispatch and streaming behavior for title generation and other auxiliary LLM calls. API-mode selection belongs in the generic
LLMabstraction; agent response policy should remain explicit at the agent call site.Summary
LLM.generate()andLLM.agenerate()to select Responses or Chat Completions from configured API mode.LLM.generate(store=False), including mandatory subscription streaming.make_llm_completion()andamake_llm_completion().generate()/agenerate()calls.tools,store=False, callbacks, call context, andadd_security_risk_prediction=Trueexplicit only on actual agent-response calls.obj.__dict__.get(...)as forbidden dynamic attribute access while allowing ordinary mapping.get(...).UsageandResponseAPIUsagedetail fields instead of dynamic/private storage.scripts/forbidden_dynamic_attributes_baseline.jsonmay only lose entries, never add them, relative to the PR base or previous main commit.How to Test
uv run pytest -q tests/sdk/llm/test_llm.py tests/sdk/agent/test_agent_utils.py tests/sdk/agent/test_agent_step_responses_gating.py tests/sdk/agent/test_message_during_streaming_arun.py tests/sdk/agent/test_non_multimodal_image_input.py tests/sdk/context/condenser/test_llm_summarizing_condenser.py tests/sdk/conversation/test_generate_title.py tests/sdk/llm/test_cleanup_profile.py tests/sdk/hooks/test_executor.py tests/sdk/hooks/test_integration.py tests/tools/ask_oracle/test_ask_oracle.py— 257 passed.uv run pytest -q tests/agent_server/test_profiles_router.py -k 'preflight or validate or subscription'— 12 passed.uv run pytest -q tests/cross/test_check_forbidden_dynamic_attributes.py tests/sdk/agent/test_stream_context.py tests/sdk/llm/test_llm_telemetry.py tests/sdk/llm/test_llm_span_cost.py— 83 passed.uv run python scripts/check_forbidden_dynamic_attributes.py --baseline-ref origin/fix/async-secret-masking— passed; current baseline is a strict subset.uv run pre-commit run --all-files --show-diff-on-failure— all hooks passed./tmpbeing inside a Git repository, local user-agent lookup, and a WebSocket readiness environment override. The same affected change-specific tests pass in isolation.Scope
The independent diff contains generic LLM dispatch, title-generation transport fixes, direct caller migration, wrapper removal, focused policy/dispatch tests, and stacked-base pre-commit remediation.
Issue Number
Split from existing PR #3403 at the author’s request; no separate issue was created.
This PR description was updated by an AI agent (OpenHands) on behalf of @neubig.
🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)
• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server
Variants & Base Images
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimnikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:5bb59cc-pythonRun
All tags pushed for this build
About Multi-Architecture Support
5bb59cc-python) is a multi-arch manifest supporting both amd64 and arm645bb59cc-python-amd64) are also available if needed